--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 7315cf37576956c9cc984a1f0f0475d075c93f41
Parents : a5923e2
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-10T12:39:01-05:00
feat(prompt): improve prompt functionality with default value support and add new PromptDialog component for improved user interaction
Changes
25 files changed, 649 insertions(+), 65 deletions(-)
Diff
diff --git a/electron/main.js b/electron/main.js
index 0c759012..8e22148c 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -296,11 +296,11 @@ ipcMain.handle("confirm", async (event, message) => {
});
// add support for showing a prompt window via ipc
-ipcMain.handle("prompt", async (event, message) => {
+ipcMain.handle("prompt", async (event, message, defaultValue = "") => {
return await electronPrompt({
title: message,
label: "",
- value: "",
+ value: defaultValue == null ? "" : String(defaultValue),
type: "input",
inputAttrs: {
type: "text",
diff --git a/electron/preload.js b/electron/preload.js
index 7e90f896..2567aeb8 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -35,8 +35,8 @@ contextBridge.exposeInMainWorld("electron", {
},
// add support for using "prompt" in electron browser window
- prompt: async function (message) {
- return await ipcRenderer.invoke("prompt", message);
+ prompt: async function (message, defaultValue = "") {
+ return await ipcRenderer.invoke("prompt", message, defaultValue);
},
// allow relaunching app in electron browser window
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 85f79448..aa4f0f72 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -7808,6 +7808,27 @@ class ReticulumMeshChat:
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
+ # pull latest Reticulum manual from GitHub
+ @routes.post("/api/v1/docs/update-from-github")
+ async def docs_update_from_github(request):
+ try:
+ self._require_outbound_http("Reticulum docs update")
+ if not self.docs_manager:
+ return web.json_response(
+ {"error": "Documentation manager is unavailable"},
+ status=503,
+ )
+ loop = asyncio.get_running_loop()
+ success, version = await loop.run_in_executor(
+ None,
+ self.docs_manager.update_from_github,
+ )
+ return web.json_response({"success": success, "version": version})
+ except OutboundHttpBlockedError as e:
+ return web.json_response({"error": str(e)}, status=403)
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
# switch docs version
@routes.post("/api/v1/docs/switch")
async def docs_switch(request):
diff --git a/meshchatx/src/backend/docs_manager.py b/meshchatx/src/backend/docs_manager.py
index a2090a85..4bbf3274 100644
--- a/meshchatx/src/backend/docs_manager.py
+++ b/meshchatx/src/backend/docs_manager.py
@@ -7,6 +7,10 @@ import logging
import os
import re
import shutil
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
import zipfile
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
@@ -14,6 +18,16 @@ from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
BUNDLED_DOCS_SUBDIR = os.path.join("reticulum-docs-bundled", "current")
MANIFEST_FILENAME = "manifest.json"
DOC_FILE_SUFFIXES = (".md", ".txt")
+RETICULUM_DOCS_GITHUB_URL = (
+ "https://github.com/markqvist/reticulum_website/archive/refs/heads/main.zip"
+)
+RETICULUM_DOCS_ALLOWED_HOSTS = frozenset(
+ {
+ "github.com",
+ "codeload.github.com",
+ "objects.githubusercontent.com",
+ }
+)
class DocsManager:
@@ -23,8 +37,9 @@ class DocsManager:
``<public_dir>/reticulum-docs-bundled/current``. Users may upload a
replacement archive which is extracted into ``<storage_dir>/reticulum-docs``
and takes precedence at request time. Removing the user upload restores the
- bundled copy. There is no runtime download path; the manual must be staged
- at build time (see ``scripts/build/fetch_reticulum_manual.py``).
+ bundled copy. Users can also pull the latest upstream ZIP from GitHub at
+ runtime (blocked when privacy mode is enabled). Build-time staging still
+ uses ``scripts/build/fetch_reticulum_manual.py``.
"""
def __init__(self, config, public_dir, project_root=None, storage_dir=None):
@@ -823,6 +838,50 @@ class DocsManager:
logging.exception(f"Failed to upload docs: {e}")
return False
+ @staticmethod
+ def _resolve_docs_source_url(source_url=None):
+ url = (
+ source_url
+ or os.environ.get("MESHCHATX_RETICULUM_DOCS_URL")
+ or RETICULUM_DOCS_GITHUB_URL
+ ).strip()
+ if not url.lower().startswith("https://"):
+ raise ValueError("docs source URL must be https")
+ host = urllib.parse.urlparse(url).hostname or ""
+ host = host.lower()
+ if host not in RETICULUM_DOCS_ALLOWED_HOSTS and not host.endswith(
+ ".githubusercontent.com"
+ ):
+ raise ValueError(f"docs source host not allowed: {host}")
+ return url
+
+ def update_from_github(self, version=None, source_url=None, timeout=120.0):
+ """Download the Reticulum website docs ZIP and install it as a version."""
+ url = self._resolve_docs_source_url(source_url)
+ if not version:
+ version = f"github-{time.strftime('%Y%m%d-%H%M%S')}"
+
+ self.upload_status = "downloading"
+ self.upload_progress = 0
+ self.last_error = None
+
+ try:
+ req = urllib.request.Request(
+ url,
+ headers={"User-Agent": "MeshChatX-docs-update"},
+ )
+ with urllib.request.urlopen(req, timeout=timeout) as response:
+ zip_bytes = response.read()
+ if not zip_bytes:
+ raise ValueError("downloaded archive is empty")
+ success = self.upload_zip(zip_bytes, version)
+ return success, version
+ except Exception as e:
+ self.last_error = str(e)
+ self.upload_status = "error"
+ logging.exception(f"Failed to update docs from GitHub: {e}")
+ raise
+
def _extract_docs(self, zip_path, version):
safe_version = os.path.basename(version)
if not safe_version or safe_version in (".", ".."):
diff --git a/meshchatx/src/backend/markdown_renderer.py b/meshchatx/src/backend/markdown_renderer.py
index b134d2ae..7c5a2111 100644
--- a/meshchatx/src/backend/markdown_renderer.py
+++ b/meshchatx/src/backend/markdown_renderer.py
@@ -185,7 +185,15 @@ class MarkdownRenderer:
def link_repl(match):
label, url = match.group(1), match.group(2)
safe_url = _safe_href(url)
- return f'<a href="{html.escape(safe_url)}" class="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">{label}</a>'
+ is_external = any(
+ safe_url.lower().startswith(p)
+ for p in ("https://", "http://", "mailto:")
+ )
+ target = ' target="_blank" rel="noopener noreferrer"' if is_external else ""
+ return (
+ f'<a href="{html.escape(safe_url)}" class="text-blue-600 '
+ f'dark:text-blue-400 hover:underline"{target}>{label}</a>'
+ )
text = re.sub(
r"\[([^\]]+)\]\(([^)]+)\)",
diff --git a/meshchatx/src/backend/message_handler.py b/meshchatx/src/backend/message_handler.py
index 08540ee0..9de1b4ea 100644
--- a/meshchatx/src/backend/message_handler.py
+++ b/meshchatx/src/backend/message_handler.py
@@ -187,7 +187,15 @@ class MessageHandler:
OR m1.peer_hash IN (SELECT peer_hash FROM lxmf_messages WHERE title LIKE ? OR content LIKE ?))
""")
params.extend(
- [like_term, like_term, like_term, like_term, like_term, like_term, like_term],
+ [
+ like_term,
+ like_term,
+ like_term,
+ like_term,
+ like_term,
+ like_term,
+ like_term,
+ ],
)
if where_clauses:
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 0db59b1c..c9c2703f 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -476,6 +476,7 @@
/>
<Toast />
<ConfirmDialog />
+ <PromptDialog />
<CommandPalette />
<IntegrityWarningModal />
<ChangelogModal ref="changelogModal" :app-version="appInfo?.version" />
@@ -587,6 +588,7 @@ import NotificationSoundUtils from "../js/NotificationSoundUtils";
import LxmfUserIcon from "./LxmfUserIcon.vue";
import Toast from "./Toast.vue";
import ConfirmDialog from "./ConfirmDialog.vue";
+import PromptDialog from "./PromptDialog.vue";
import ToastUtils from "../js/ToastUtils";
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import QRCode from "qrcode";
@@ -616,6 +618,7 @@ export default {
SidebarLink,
Toast,
ConfirmDialog,
+ PromptDialog,
MaterialDesignIcon,
NotificationBell,
LanguageSelector,
diff --git a/meshchatx/src/frontend/components/PromptDialog.vue b/meshchatx/src/frontend/components/PromptDialog.vue
new file mode 100644
index 00000000..6f7180a1
--- /dev/null
+++ b/meshchatx/src/frontend/components/PromptDialog.vue
@@ -0,0 +1,131 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <Transition name="prompt-dialog">
+ <div v-if="pendingPrompt" class="fixed inset-0 z-9999 flex items-center justify-center p-4">
+ <div class="fixed inset-0 bg-black/50 backdrop-blur-xs shadow-2xl" @click="cancel"></div>
+
+ <div
+ class="relative w-full sm:w-auto sm:min-w-[400px] sm:max-w-md bg-white dark:bg-zinc-900 sm:rounded-3xl rounded-3xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden transform transition-all"
+ @click.stop
+ >
+ <div class="p-8">
+ <div class="flex items-start mb-6">
+ <div
+ class="shrink-0 flex items-center justify-center w-12 h-12 rounded-2xl bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 mr-4"
+ >
+ <MaterialDesignIcon icon-name="form-textbox" class="w-6 h-6" />
+ </div>
+ <div class="flex-1 min-w-0">
+ <h3 class="text-xl font-black text-gray-900 dark:text-white mb-2">
+ {{ $t("common.prompt_title") }}
+ </h3>
+ <p class="text-gray-600 dark:text-zinc-300 whitespace-pre-wrap leading-relaxed">
+ {{ pendingPrompt.message }}
+ </p>
+ </div>
+ </div>
+
+ <input
+ ref="promptInput"
+ v-model="inputValue"
+ type="text"
+ class="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500"
+ @keydown.enter.prevent="confirm"
+ @keydown.esc.prevent="cancel"
+ />
+
+ <div class="flex flex-col sm:flex-row gap-3 sm:justify-end mt-8">
+ <button
+ type="button"
+ class="px-6 py-3 text-sm font-bold text-gray-700 dark:text-zinc-300 bg-gray-100 dark:bg-zinc-800 rounded-xl hover:bg-gray-200 dark:hover:bg-zinc-700 transition-all active:scale-95"
+ @click="cancel"
+ >
+ {{ $t("common.cancel") }}
+ </button>
+ <button
+ type="button"
+ class="px-6 py-3 text-sm font-bold text-white bg-blue-600 hover:bg-blue-700 rounded-xl shadow-lg shadow-blue-600/20 transition-all active:scale-95"
+ @click="confirm"
+ >
+ {{ $t("common.ok") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </Transition>
+</template>
+
+<script>
+import GlobalEmitter from "../js/GlobalEmitter";
+import MaterialDesignIcon from "./MaterialDesignIcon.vue";
+
+export default {
+ name: "PromptDialog",
+ components: {
+ MaterialDesignIcon,
+ },
+ data() {
+ return {
+ pendingPrompt: null,
+ resolvePromise: null,
+ inputValue: "",
+ };
+ },
+ mounted() {
+ GlobalEmitter.on("prompt", this.show);
+ },
+ beforeUnmount() {
+ GlobalEmitter.off("prompt", this.show);
+ },
+ methods: {
+ show({ message, defaultValue, resolve }) {
+ this.pendingPrompt = { message };
+ this.inputValue = defaultValue == null ? "" : String(defaultValue);
+ this.resolvePromise = resolve;
+ this.$nextTick(() => {
+ const input = this.$refs.promptInput;
+ if (input && typeof input.focus === "function") {
+ input.focus();
+ input.select();
+ }
+ });
+ },
+ confirm() {
+ if (this.resolvePromise) {
+ this.resolvePromise(this.inputValue);
+ this.resolvePromise = null;
+ }
+ this.pendingPrompt = null;
+ this.inputValue = "";
+ },
+ cancel() {
+ if (this.resolvePromise) {
+ this.resolvePromise(null);
+ this.resolvePromise = null;
+ }
+ this.pendingPrompt = null;
+ this.inputValue = "";
+ },
+ },
+};
+</script>
+
+<style scoped>
+.prompt-dialog-enter-active,
+.prompt-dialog-leave-active {
+ transition: all 0.2s ease;
+}
+
+.prompt-dialog-enter-from,
+.prompt-dialog-leave-to {
+ opacity: 0;
+}
+
+.prompt-dialog-enter-from .relative,
+.prompt-dialog-leave-to .relative {
+ transform: scale(0.95);
+ opacity: 0;
+}
+</style>
diff --git a/meshchatx/src/frontend/components/docs/DocsPage.vue b/meshchatx/src/frontend/components/docs/DocsPage.vue
index 65933b9c..e4c08c9d 100644
--- a/meshchatx/src/frontend/components/docs/DocsPage.vue
+++ b/meshchatx/src/frontend/components/docs/DocsPage.vue
@@ -186,6 +186,22 @@
<MaterialDesignIcon icon-name="download" class="w-4 h-4 md:w-5 md:h-5" />
</button>
+ <!-- Update from GitHub -->
+ <button
+ type="button"
+ class="p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
+ :class="{ 'opacity-50 pointer-events-none': docsBusy }"
+ :title="$t('docs.btn_update_github')"
+ :disabled="docsBusy"
+ @click="updateFromGithub"
+ >
+ <MaterialDesignIcon
+ :icon-name="docsBusy ? 'loading' : 'update'"
+ :class="{ 'animate-spin': docsBusy }"
+ class="w-4 h-4 md:w-5 md:h-5"
+ />
+ </button>
+
<!-- Share Reticulum Manual (re-uploadable ZIP) -->
<button
v-if="status.has_docs"
@@ -198,16 +214,16 @@
<!-- Upload Custom Manual -->
<label
- :class="{ 'opacity-50 pointer-events-none': status.status === 'extracting' }"
+ :class="{ 'opacity-50 pointer-events-none': docsBusy }"
class="p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors cursor-pointer"
:title="$t('docs.btn_upload')"
>
<MaterialDesignIcon
- :icon-name="status.status === 'extracting' ? 'loading' : 'upload'"
- :class="{ 'animate-spin': status.status === 'extracting' }"
+ :icon-name="docsBusy ? 'loading' : 'upload'"
+ :class="{ 'animate-spin': docsBusy }"
class="w-4 h-4 md:w-5 md:h-5"
/>
- <input type="file" accept=".zip" class="hidden" @change="handleZipUpload" />
+ <input type="file" accept=".zip" class="hidden" :disabled="docsBusy" @change="handleZipUpload" />
</label>
<!-- Open External -->
@@ -285,10 +301,7 @@
</div>
<!-- Progress Bar -->
- <div
- v-if="status.status === 'extracting'"
- class="w-full h-1 bg-gray-200 dark:bg-zinc-800 overflow-hidden relative"
- >
+ <div v-if="docsBusy" class="w-full h-1 bg-gray-200 dark:bg-zinc-800 overflow-hidden relative">
<div class="bg-blue-500 h-full transition-all duration-300" :style="{ width: status.progress + '%' }"></div>
<div class="absolute inset-0 bg-blue-500/30 animate-pulse"></div>
</div>
@@ -409,7 +422,7 @@
</div>
<div
- v-if="status.status === 'extracting'"
+ v-if="docsBusy"
class="absolute inset-0 z-10 flex flex-col items-center justify-center bg-white/80 dark:bg-zinc-900/80 backdrop-blur-md"
>
<div class="relative w-24 h-24 mb-6">
@@ -421,13 +434,13 @@
></div>
<div class="absolute inset-0 flex items-center justify-center">
<MaterialDesignIcon
- icon-name="folder-zip-outline"
+ :icon-name="status.status === 'downloading' ? 'cloud-download' : 'folder-zip-outline'"
class="w-10 h-10 text-blue-600 animate-bounce"
/>
</div>
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-zinc-100 mb-1">
- {{ $t("docs.status_extracting") }}
+ {{ status.status === "downloading" ? $t("docs.status_downloading") : $t("docs.status_extracting") }}
</h3>
<p class="text-sm text-gray-500 dark:text-zinc-400">
{{ $t("docs.complete_percent", { percent: status.progress }) }}
@@ -537,6 +550,7 @@
<article
ref="docsProse"
class="docs-prose max-w-none wrap-break-word"
+ @click="handleDocClick"
v-html="selectedDocContent.html"
></article>
</div>
@@ -603,9 +617,7 @@
></iframe>
<div
- v-else-if="
- activeTab === 'reticulum' && !status.has_docs && status.status !== 'extracting' && !searchQuery
- "
+ v-else-if="activeTab === 'reticulum' && !status.has_docs && !docsBusy && !searchQuery"
class="h-full flex flex-col items-center justify-center p-8 text-center space-y-4"
>
<div class="w-16 h-16 bg-gray-50 dark:bg-zinc-800/50 rounded-full flex items-center justify-center">
@@ -619,13 +631,24 @@
{{ $t("docs.empty_state_hint") }}
</p>
</div>
- <label
- class="px-6 py-2 bg-blue-600 text-white rounded-full text-xs font-bold hover:bg-blue-700 transition-colors shadow-lg shadow-blue-500/20 cursor-pointer flex items-center gap-2"
- >
- <MaterialDesignIcon icon-name="upload" class="w-3.5 h-3.5" />
- <span>{{ $t("docs.btn_upload") }}</span>
- <input type="file" accept=".zip" class="hidden" @change="handleZipUpload" />
- </label>
+ <div class="flex flex-col sm:flex-row gap-2">
+ <button
+ type="button"
+ class="px-6 py-2 bg-blue-600 text-white rounded-full text-xs font-bold hover:bg-blue-700 transition-colors shadow-lg shadow-blue-500/20 flex items-center justify-center gap-2"
+ :disabled="docsBusy"
+ @click="updateFromGithub"
+ >
+ <MaterialDesignIcon icon-name="update" class="w-3.5 h-3.5" />
+ <span>{{ $t("docs.btn_update_github") }}</span>
+ </button>
+ <label
+ class="px-6 py-2 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-full text-xs font-bold hover:opacity-90 transition-opacity cursor-pointer flex items-center justify-center gap-2"
+ >
+ <MaterialDesignIcon icon-name="upload" class="w-3.5 h-3.5" />
+ <span>{{ $t("docs.btn_upload") }}</span>
+ <input type="file" accept=".zip" class="hidden" @change="handleZipUpload" />
+ </label>
+ </div>
</div>
</div>
</div>
@@ -634,6 +657,7 @@
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToastUtils from "../../js/ToastUtils";
+import DialogUtils from "../../js/DialogUtils";
import GlobalState from "../../js/GlobalState";
import { bundledReticulumDocsUrl } from "../../js/reticulumDocsEntryUrl.js";
import ToolsPageHeader from "../tools/ToolsPageHeader.vue";
@@ -675,6 +699,7 @@ export default {
selectedDocPath: null,
selectedDocContent: null,
selectedReticulumPath: null,
+ githubUpdatePending: false,
languages: {
en: "English",
de: "Deutsch",
@@ -712,6 +737,11 @@ export default {
reticulumDocsQueryParam() {
return this.$route?.query?.reticulum;
},
+ docsBusy() {
+ return (
+ this.githubUpdatePending || this.status.status === "downloading" || this.status.status === "extracting"
+ );
+ },
visibleDocSections() {
const lang = this.meshchatxDocsLang;
const fallback = this.defaultDocsLanguage || "en";
@@ -894,7 +924,7 @@ export default {
}
},
async deleteVersion(version) {
- if (!confirm(this.$t("docs.confirm_delete_version", { version }))) {
+ if (!(await DialogUtils.confirm(this.$t("docs.confirm_delete_version", { version })))) {
return;
}
@@ -911,23 +941,64 @@ export default {
const file = event.target.files[0];
if (!file) return;
- const version = prompt(this.$t("docs.prompt_version_name"), `upload-${Date.now()}`);
- if (!version) return;
+ const defaultName = `upload-${Date.now()}`;
+ const version = await DialogUtils.prompt(this.$t("docs.prompt_version_name"), defaultName);
+ // Reset input so the same file can be chosen again after cancel.
+ event.target.value = "";
+ if (version === null || !String(version).trim()) {
+ return;
+ }
const formData = new FormData();
formData.append("file", file);
try {
- await window.api.post(`/api/v1/docs/upload?version=${encodeURIComponent(version)}`, formData, {
- headers: {
- "Content-Type": "multipart/form-data",
- },
- });
+ await window.api.post(
+ `/api/v1/docs/upload?version=${encodeURIComponent(String(version).trim())}`,
+ formData,
+ {
+ headers: {
+ "Content-Type": "multipart/form-data",
+ },
+ }
+ );
this.fetchStatus();
+ ToastUtils.success(this.$t("docs.upload_success"));
} catch (error) {
console.error("Failed to upload docs zip:", error);
const message = error.response?.data?.error || error.message || "";
- alert(this.$t("docs.failed_upload_alert", { message }));
+ DialogUtils.alert(this.$t("docs.failed_upload_alert", { message }), "error");
+ }
+ },
+ async updateFromGithub() {
+ if (this.docsBusy) {
+ return;
+ }
+ this.githubUpdatePending = true;
+ this.status = {
+ ...this.status,
+ status: "downloading",
+ progress: 0,
+ last_error: null,
+ };
+ try {
+ const response = await window.api.post("/api/v1/docs/update-from-github");
+ const version = response.data?.version;
+ await this.fetchStatus();
+ this.activeTab = "reticulum";
+ this.selectedReticulumPath = null;
+ ToastUtils.success(
+ version
+ ? this.$t("docs.update_github_success_version", { version })
+ : this.$t("docs.update_github_success")
+ );
+ } catch (error) {
+ console.error("Failed to update docs from GitHub:", error);
+ const message = error.response?.data?.error || error.message || "";
+ DialogUtils.alert(this.$t("docs.failed_update_github", { message }), "error");
+ await this.fetchStatus();
+ } finally {
+ this.githubUpdatePending = false;
}
},
async exportDocs() {
@@ -1051,6 +1122,52 @@ export default {
'<span class="bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 px-0.5 rounded-sm">$1</span>'
);
},
+ handleDocClick(event) {
+ const link = event.target.closest("a");
+ if (!link) return;
+
+ const href = link.getAttribute("href");
+ if (!href) return;
+
+ // If it's an external link, let the browser handle it (it will open in a new tab due to target="_blank" from renderer)
+ if (href.startsWith("http") || href.startsWith("mailto:") || href.startsWith("/")) {
+ return;
+ }
+
+ // If it's a hash link, use the smooth scroll helper
+ if (href.startsWith("#")) {
+ event.preventDefault();
+ this.scrollToHeading(href.substring(1));
+ return;
+ }
+
+ // If it's a relative link to another markdown file
+ if (href.endsWith(".md") || href.endsWith(".txt")) {
+ event.preventDefault();
+
+ // Resolve relative path
+ const currentPath = this.selectedDocPath || "";
+ const parts = currentPath.split("/");
+ parts.pop(); // remove current filename
+
+ const hrefParts = href.split("/");
+ for (const part of hrefParts) {
+ if (part === "..") {
+ parts.pop();
+ } else if (part !== ".") {
+ parts.push(part);
+ }
+ }
+
+ const newPath = parts.join("/");
+ this.selectDoc(newPath);
+
+ // Scroll to top
+ if (this.$refs.docContentScroller) {
+ this.$refs.docContentScroller.scrollTop = 0;
+ }
+ }
+ },
},
};
</script>
diff --git a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
index ef6a591c..743f2a11 100644
--- a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
+++ b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
@@ -1643,7 +1643,7 @@
</button>
<button
type="button"
- class="primary-chip px-4 py-2 text-sm"
+ class="primary-chip px-4 py-2 text-sm"
:disabled="isSaving"
@click="saveInterface"
>
diff --git a/meshchatx/src/frontend/components/interfaces/Interface.vue b/meshchatx/src/frontend/components/interfaces/Interface.vue
index 2b40f20c..188b903d 100644
--- a/meshchatx/src/frontend/components/interfaces/Interface.vue
+++ b/meshchatx/src/frontend/components/interfaces/Interface.vue
@@ -5,7 +5,8 @@
class="interface-card min-w-0 transition-all duration-300"
:class="{
'opacity-60 grayscale-[0.5]': !isInterfaceEnabled(iface) || iface._restart_required || !isReticulumRunning,
- 'border-amber-500 ring-amber-500 ring-2': iface._restart_required && showRestartBanner && isReticulumRunning,
+ 'border-amber-500 ring-amber-500 ring-2':
+ iface._restart_required && showRestartBanner && isReticulumRunning,
}"
>
<div class="flex flex-col sm:flex-row gap-4 sm:items-start relative pt-11 sm:pt-0">
diff --git a/meshchatx/src/frontend/js/DialogUtils.js b/meshchatx/src/frontend/js/DialogUtils.js
index 4ddede80..666ed77a 100644
--- a/meshchatx/src/frontend/js/DialogUtils.js
+++ b/meshchatx/src/frontend/js/DialogUtils.js
@@ -31,14 +31,21 @@ class DialogUtils {
});
}
- static async prompt(message) {
- if (window.electron) {
- // running inside electron, use ipc prompt
- return await window.electron.prompt(message);
- } else {
- // running inside normal browser, use browser prompt
- return window.prompt(message);
+ static async prompt(message, defaultValue = "") {
+ if (window.electron && typeof window.electron.prompt === "function") {
+ try {
+ return await window.electron.prompt(message, defaultValue);
+ } catch {
+ // Fall through to in-app dialog when IPC prompt fails.
+ }
}
+ return new Promise((resolve) => {
+ GlobalEmitter.emit("prompt", {
+ message,
+ defaultValue: defaultValue == null ? "" : String(defaultValue),
+ resolve,
+ });
+ });
}
}
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 32cd2012..de4c7577 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -521,7 +521,8 @@
"ok": "OK",
"copy": "Kopieren",
"copy_to_clipboard": "In die Zwischenablage kopieren",
- "clear": "Löschen"
+ "clear": "Löschen",
+ "prompt_title": "Wert eingeben"
},
"stickers": {
"settings_title": "Sticker",
@@ -1966,13 +1967,19 @@
"prompt_version_name": "Versionsname für diesen Upload eingeben:",
"status_title": "Dokumentationsstatus",
"status_extracting": "Wird entpackt...",
+ "status_downloading": "Dokumentation wird heruntergeladen...",
"status_available": "Offline-Handbuch verfügbar",
"status_not_available": "Handbuch nicht verfügbar",
"btn_upload": "Handbuch hochladen",
+ "btn_update_github": "Von GitHub aktualisieren",
"btn_share": "Reticulum-Handbuch als wieder hochladbares ZIP teilen",
- "empty_state_hint": "Lade ein ZIP des Reticulum-Handbuchs hoch, um es offline anzuzeigen. Eigene oder aktualisierte Dokumentation wird unterstützt.",
+ "empty_state_hint": "Lade das neueste Reticulum-Handbuch von GitHub, oder lade ein ZIP hoch, um es offline anzuzeigen.",
"error": "Fehler",
"failed_upload_docs": "Hochladen der Dokumentation fehlgeschlagen",
+ "upload_success": "Dokumentation hochgeladen",
+ "update_github_success": "Reticulum-Handbuch von GitHub aktualisiert",
+ "update_github_success_version": "Reticulum-Handbuch aktualisiert ({version})",
+ "failed_update_github": "Aktualisierung von GitHub fehlgeschlagen: {message}",
"docs_link_copied": "Dokumentationslink in Zwischenablage kopiert",
"failed_copy_link": "Link kopieren fehlgeschlagen",
"load_list_failed": "Anleitungsliste konnte nicht geladen werden. Bitte später erneut versuchen.",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 1fd625a3..b5ef2c78 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -521,7 +521,8 @@
"invalid_address": "Invalid Address",
"loading": "Loading...",
"ok": "OK",
- "clear": "Clear"
+ "clear": "Clear",
+ "prompt_title": "Enter a value"
},
"stickers": {
"settings_title": "Stickers",
@@ -2086,10 +2087,16 @@
"status_available": "Offline Manual Available",
"status_not_available": "Manual Not Available",
"btn_upload": "Upload Manual",
+ "btn_update_github": "Update from GitHub",
"btn_share": "Share Reticulum manual as a re-uploadable ZIP",
- "empty_state_hint": "Upload a Reticulum manual ZIP to view it offline. Custom or updated documentation is supported.",
+ "empty_state_hint": "Pull the latest Reticulum manual from GitHub, or upload a ZIP to view it offline.",
"error": "Error",
"failed_upload_docs": "Failed to upload documentation",
+ "upload_success": "Documentation uploaded",
+ "update_github_success": "Reticulum manual updated from GitHub",
+ "update_github_success_version": "Reticulum manual updated ({version})",
+ "failed_update_github": "Failed to update from GitHub: {message}",
+ "status_downloading": "Downloading Documentation...",
"docs_link_copied": "Documentation link copied to clipboard",
"failed_copy_link": "Failed to copy link",
"load_list_failed": "Could not load the guide list. Try again in a moment.",
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 1114a881..cc6aa30b 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -521,7 +521,8 @@
"invalid_address": "Dirección inválida",
"loading": "Cargando...",
"ok": "Aceptar",
- "clear": "Limpiar"
+ "clear": "Limpiar",
+ "prompt_title": "Introduce un valor"
},
"stickers": {
"settings_title": "Pegatinas",
@@ -2083,13 +2084,19 @@
"prompt_version_name": "Introduce un nombre de versión para esta subida:",
"status_title": "Situación de la documentación",
"status_extracting": "Extrayendo...",
+ "status_downloading": "Descargando documentación...",
"status_available": "Manual disponible",
"status_not_available": "Manual no disponible",
"btn_upload": "Subir Manual",
+ "btn_update_github": "Actualizar desde GitHub",
"btn_share": "Compartir el manual de Reticulum como ZIP reimportable",
- "empty_state_hint": "Sube un ZIP del manual de Reticulum para consultarlo sin conexión. Se admite documentación personalizada o actualizada.",
+ "empty_state_hint": "Descarga el manual de Reticulum más reciente desde GitHub, o sube un ZIP para verlo sin conexión.",
"error": "Error",
"failed_upload_docs": "Error al subir la documentación",
+ "upload_success": "Documentación subida",
+ "update_github_success": "Manual de Reticulum actualizado desde GitHub",
+ "update_github_success_version": "Manual de Reticulum actualizado ({version})",
+ "failed_update_github": "Error al actualizar desde GitHub: {message}",
"docs_link_copied": "Enlace de documentación copiado al portapapeles",
"failed_copy_link": "No se pudo copiar el enlace",
"load_list_failed": "No se pudo cargar la lista de guías. Inténtalo de nuevo en un momento.",
diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index dd370502..4333435f 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -521,7 +521,8 @@
"invalid_address": "Kelvoton kohde",
"loading": "Ladataan...",
"ok": "OK",
- "clear": "Tyhjää"
+ "clear": "Tyhjää",
+ "prompt_title": "Anna arvo"
},
"stickers": {
"settings_title": "Tarrat",
@@ -2083,13 +2084,19 @@
"prompt_version_name": "Anna tälle lataukselle version nimi:",
"status_title": "Dokumentaation tila",
"status_extracting": "Viedään dokumentaatiota...",
+ "status_downloading": "Ladataan dokumentaatiota...",
"status_available": "Paikallinen ohjekirja saatavilla",
"status_not_available": "Ohjekirja ei ole saatavilla",
"btn_upload": "Tuo ohjekirja",
+ "btn_update_github": "Päivitä GitHubista",
"btn_share": "Jaa Reticulum-ohjekirja ZIP-tiedostona, jonka voi myöhemmin tuoda sovellukseen",
- "empty_state_hint": "Tuo Reticulum-ohjekirjan ZIP-tiedosto käyttääksesi sitä paikallisesti. Mukautukset ja päivitykset ovat tuettuja.",
+ "empty_state_hint": "Hae uusin Reticulum-ohjekirja GitHubista tai tuo ZIP paikallista käyttöä varten.",
"error": "Virhe",
"failed_upload_docs": "Dokumentaation tuominen epäonnistui",
+ "upload_success": "Dokumentaatio ladattu",
+ "update_github_success": "Reticulum-ohjekirja päivitetty GitHubista",
+ "update_github_success_version": "Reticulum-ohjekirja päivitetty ({version})",
+ "failed_update_github": "Päivitys GitHubista epäonnistui: {message}",
"docs_link_copied": "Dokumentaation linkki kopioitu leikepöydälle",
"failed_copy_link": "Linkin kopiointi epäonnistui",
"load_list_failed": "Ohjeluetteloa ei voitu ladata. Yritä hetken kuluttua uudelleen.",
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index c48ce274..35719f14 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -521,7 +521,8 @@
"invalid_address": "Adresse non valable",
"loading": "Chargement...",
"ok": "Très bien.",
- "clear": "Effacer"
+ "clear": "Effacer",
+ "prompt_title": "Saisir une valeur"
},
"stickers": {
"settings_title": "Autocollants",
@@ -2083,13 +2084,19 @@
"prompt_version_name": "Entrez un nom de version pour ce téléversement :",
"status_title": "État de la documentation",
"status_extracting": "Extraction...",
+ "status_downloading": "Téléchargement de la documentation...",
"status_available": "Manuel hors ligne disponible",
"status_not_available": "Manuel non disponible",
"btn_upload": "Téléverser le manuel",
+ "btn_update_github": "Mettre à jour depuis GitHub",
"btn_share": "Partager le manuel Reticulum sous forme de ZIP réimportable",
- "empty_state_hint": "Téléversez une archive ZIP du manuel Reticulum pour la consulter hors ligne. La documentation personnalisée ou mise à jour est prise en charge.",
+ "empty_state_hint": "Récupérez le dernier manuel Reticulum depuis GitHub, ou téléversez une archive ZIP pour le consulter hors ligne.",
"error": "Erreur",
"failed_upload_docs": "Échec du téléversement de la documentation",
+ "upload_success": "Documentation téléversée",
+ "update_github_success": "Manuel Reticulum mis à jour depuis GitHub",
+ "update_github_success_version": "Manuel Reticulum mis à jour ({version})",
+ "failed_update_github": "Échec de la mise à jour depuis GitHub : {message}",
"docs_link_copied": "Lien de documentation copié dans le presse-papiers",
"failed_copy_link": "Impossible de copier le lien",
"load_list_failed": "Impossible de charger la liste des guides. Réessayez dans un instant.",
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 1e432581..d7758794 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -521,7 +521,8 @@
"invalid_address": "Indirizzo Non Valido",
"loading": "Caricamento...",
"ok": "OK",
- "clear": "Cancella"
+ "clear": "Cancella",
+ "prompt_title": "Inserisci un valore"
},
"stickers": {
"settings_title": "Sticker",
@@ -2135,13 +2136,19 @@
"prompt_version_name": "Inserisci un nome di versione per questo caricamento:",
"status_title": "Stato Documentazione",
"status_extracting": "Estrazione in corso...",
+ "status_downloading": "Download della documentazione...",
"status_available": "Manuale Offline Disponibile",
"status_not_available": "Manuale Non Disponibile",
"btn_upload": "Carica Manuale",
+ "btn_update_github": "Aggiorna da GitHub",
"btn_share": "Condividi il manuale Reticulum come ZIP ricaricabile",
- "empty_state_hint": "Carica un archivio ZIP del manuale Reticulum per consultarlo offline. È supportata anche documentazione personalizzata o aggiornata.",
+ "empty_state_hint": "Scarica l'ultimo manuale Reticulum da GitHub, oppure carica uno ZIP per consultarlo offline.",
"error": "Errore",
"failed_upload_docs": "Caricamento della documentazione non riuscito",
+ "upload_success": "Documentazione caricata",
+ "update_github_success": "Manuale Reticulum aggiornato da GitHub",
+ "update_github_success_version": "Manuale Reticulum aggiornato ({version})",
+ "failed_update_github": "Aggiornamento da GitHub non riuscito: {message}",
"docs_link_copied": "Link alla documentazione copiato negli appunti",
"failed_copy_link": "Impossibile copiare il link",
"load_list_failed": "Impossibile caricare l'elenco delle guide. Riprova tra un momento.",
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 2f042fa4..dedbba5c 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -521,7 +521,8 @@
"invalid_address": "Ongeldig adres",
"loading": "Laden...",
"ok": "OK",
- "clear": "Wissen"
+ "clear": "Wissen",
+ "prompt_title": "Voer een waarde in"
},
"stickers": {
"settings_title": "Stickers",
@@ -2083,13 +2084,19 @@
"prompt_version_name": "Voer een versienaam in voor deze upload:",
"status_title": "Documentatiestatus",
"status_extracting": "Uitpakken...",
+ "status_downloading": "Documentatie downloaden...",
"status_available": "Offline handleiding beschikbaar",
"status_not_available": "Handleiding niet beschikbaar",
"btn_upload": "Handleiding uploaden",
+ "btn_update_github": "Bijwerken vanaf GitHub",
"btn_share": "Reticulum-handleiding delen als opnieuw uploadbare ZIP",
- "empty_state_hint": "Upload een ZIP van de Reticulum-handleiding om deze offline te bekijken. Aangepaste of bijgewerkte documentatie wordt ondersteund.",
+ "empty_state_hint": "Haal de nieuwste Reticulum-handleiding van GitHub, of upload een ZIP om deze offline te bekijken.",
"error": "Fout",
"failed_upload_docs": "Documentatie uploaden mislukt",
+ "upload_success": "Documentatie geüpload",
+ "update_github_success": "Reticulum-handleiding bijgewerkt vanaf GitHub",
+ "update_github_success_version": "Reticulum-handleiding bijgewerkt ({version})",
+ "failed_update_github": "Bijwerken vanaf GitHub mislukt: {message}",
"docs_link_copied": "Documentatiekoppeling gekopieerd naar klembord",
"failed_copy_link": "Kopiëren van verwijzing is mislukt",
"load_list_failed": "Kan de gidsenlijst niet laden. Probeer het zo opnieuw.",
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 6b622d77..1cbd69a4 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -521,7 +521,8 @@
"ok": "ОК",
"copy": "Копировать",
"copy_to_clipboard": "Копировать в буфер обмена",
- "clear": "Очистить"
+ "clear": "Очистить",
+ "prompt_title": "Введите значение"
},
"stickers": {
"settings_title": "Стикеры",
@@ -1966,13 +1967,19 @@
"prompt_version_name": "Введите имя версии для этой загрузки:",
"status_title": "Статус документации",
"status_extracting": "Извлечение...",
+ "status_downloading": "Загрузка документации...",
"status_available": "Доступно офлайн",
"status_not_available": "Руководство недоступно",
"btn_upload": "Загрузить руководство",
+ "btn_update_github": "Обновить с GitHub",
"btn_share": "Поделиться руководством Reticulum как загружаемым ZIP",
- "empty_state_hint": "Загрузите ZIP-архив с руководством Reticulum для просмотра офлайн. Поддерживается пользовательская и обновлённая документация.",
+ "empty_state_hint": "Загрузите актуальное руководство Reticulum с GitHub или загрузите ZIP для офлайн-просмотра.",
"error": "Ошибка",
"failed_upload_docs": "Не удалось загрузить документацию",
+ "upload_success": "Документация загружена",
+ "update_github_success": "Руководство Reticulum обновлено с GitHub",
+ "update_github_success_version": "Руководство Reticulum обновлено ({version})",
+ "failed_update_github": "Не удалось обновить с GitHub: {message}",
"docs_link_copied": "Ссылка на документацию скопирована в буфер обмена",
"failed_copy_link": "Не удалось скопировать ссылку",
"load_list_failed": "Не удалось загрузить список справок. Повторите попытку позже.",
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index b4edf103..7c146be4 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -521,7 +521,8 @@
"invalid_address": "无效的地址",
"loading": "正在加载...",
"ok": "确定",
- "clear": "清除"
+ "clear": "清除",
+ "prompt_title": "输入值"
},
"stickers": {
"settings_title": "贴纸",
@@ -2083,13 +2084,19 @@
"prompt_version_name": "为此上传输入版本名称:",
"status_title": "文件状况",
"status_extracting": "正在解压...",
+ "status_downloading": "正在下载文档...",
"status_available": "离线手册可用",
"status_not_available": "手册不可用",
"btn_upload": "上传手册",
+ "btn_update_github": "从 GitHub 更新",
"btn_share": "以可重新上传的 ZIP 共享 Reticulum 手册",
- "empty_state_hint": "上传 Reticulum 手册的 ZIP 以离线查看。支持自定义或更新后的文档。",
+ "empty_state_hint": "从 GitHub 拉取最新 Reticulum 手册,或上传 ZIP 以便离线查看。",
"error": "错误",
"failed_upload_docs": "上传文档失败",
+ "upload_success": "文档已上传",
+ "update_github_success": "已从 GitHub 更新 Reticulum 手册",
+ "update_github_success_version": "已更新 Reticulum 手册({version})",
+ "failed_update_github": "从 GitHub 更新失败:{message}",
"docs_link_copied": "复制到剪贴板的文档链接",
"failed_copy_link": "复制链接失败",
"load_list_failed": "无法加载指南列表。请稍后再试。",
diff --git a/tests/backend/fixtures/http_api_routes.json b/tests/backend/fixtures/http_api_routes.json
index 6d686ebd..80d63f75 100644
--- a/tests/backend/fixtures/http_api_routes.json
+++ b/tests/backend/fixtures/http_api_routes.json
@@ -268,6 +268,10 @@
"method": "POST",
"path": "/api/v1/docs/upload"
},
+ {
+ "method": "POST",
+ "path": "/api/v1/docs/update-from-github"
+ },
{
"method": "DELETE",
"path": "/api/v1/docs/version/{version}"
diff --git a/tests/backend/test_docs_manager.py b/tests/backend/test_docs_manager.py
index 5bf1eafc..6a703f1c 100644
--- a/tests/backend/test_docs_manager.py
+++ b/tests/backend/test_docs_manager.py
@@ -544,3 +544,47 @@ def test_get_doc_content_returns_none_on_read_error(tmp_path, monkeypatch):
monkeypatch.setattr("builtins.open", fail_open)
assert dm.get_doc_content("en/guide.md") is None
+
+
+def test_resolve_docs_source_url_rejects_non_https():
+ with pytest.raises(ValueError, match="https"):
+ DocsManager._resolve_docs_source_url(
+ "http://github.com/markqvist/reticulum_website/archive/refs/heads/main.zip"
+ )
+
+
+def test_resolve_docs_source_url_rejects_unknown_host():
+ with pytest.raises(ValueError, match="not allowed"):
+ DocsManager._resolve_docs_source_url("https://evil.example/docs.zip")
+
+
+def test_update_from_github_downloads_and_installs(docs_manager, monkeypatch):
+ payload = _make_docs_zip(
+ files={
+ "reticulum_website-main/docs/index.html": "<html>github</html>",
+ },
+ )
+
+ class FakeResponse:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_args):
+ return False
+
+ def read(self):
+ return payload
+
+ monkeypatch.setattr(
+ "meshchatx.src.backend.docs_manager.urllib.request.urlopen",
+ lambda *_args, **_kwargs: FakeResponse(),
+ )
+
+ success, version = docs_manager.update_from_github(version="github-test")
+ assert success is True
+ assert version == "github-test"
+ assert docs_manager.upload_status == "completed"
+ resolved = docs_manager.find_docs_file("index.html")
+ assert resolved is not None
+ with open(resolved) as fh:
+ assert "github" in fh.read()
diff --git a/tests/frontend/DialogUtils.prompt.test.js b/tests/frontend/DialogUtils.prompt.test.js
new file mode 100644
index 00000000..851e933e
--- /dev/null
+++ b/tests/frontend/DialogUtils.prompt.test.js
@@ -0,0 +1,49 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({
+ default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
+}));
+
+import DialogUtils from "../../meshchatx/src/frontend/js/DialogUtils.js";
+import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
+
+describe("DialogUtils.prompt", () => {
+ beforeEach(() => {
+ vi.mocked(GlobalEmitter.emit).mockClear();
+ delete window.electron;
+ });
+
+ it("uses in-app prompt dialog when electron is unavailable", async () => {
+ const pending = DialogUtils.prompt("Enter version", "upload-1");
+ expect(GlobalEmitter.emit).toHaveBeenCalledWith(
+ "prompt",
+ expect.objectContaining({
+ message: "Enter version",
+ defaultValue: "upload-1",
+ resolve: expect.any(Function),
+ })
+ );
+ const payload = GlobalEmitter.emit.mock.calls.find((c) => c[0] === "prompt")[1];
+ payload.resolve("named");
+ await expect(pending).resolves.toBe("named");
+ });
+
+ it("falls back to in-app dialog when electron.prompt throws", async () => {
+ window.electron = {
+ prompt: vi.fn().mockRejectedValue(new Error("prompt() is not supported.")),
+ };
+ const pending = DialogUtils.prompt("Enter version", "fallback");
+ await Promise.resolve();
+ expect(window.electron.prompt).toHaveBeenCalledWith("Enter version", "fallback");
+ expect(GlobalEmitter.emit).toHaveBeenCalledWith(
+ "prompt",
+ expect.objectContaining({
+ message: "Enter version",
+ defaultValue: "fallback",
+ })
+ );
+ const payload = GlobalEmitter.emit.mock.calls.find((c) => c[0] === "prompt")[1];
+ payload.resolve(null);
+ await expect(pending).resolves.toBeNull();
+ });
+});
diff --git a/tests/frontend/PromptDialog.test.js b/tests/frontend/PromptDialog.test.js
new file mode 100644
index 00000000..a38501be
--- /dev/null
+++ b/tests/frontend/PromptDialog.test.js
@@ -0,0 +1,69 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import PromptDialog from "../../meshchatx/src/frontend/components/PromptDialog.vue";
+
+vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({
+ default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
+}));
+
+import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
+
+const MaterialDesignIcon = { template: '<div class="mdi"></div>', props: ["iconName"] };
+
+function mountDialog() {
+ return mount(PromptDialog, {
+ global: {
+ components: { MaterialDesignIcon },
+ mocks: {
+ $t: (key) => key,
+ },
+ },
+ });
+}
+
+describe("PromptDialog UI", () => {
+ beforeEach(() => {
+ vi.mocked(GlobalEmitter.on).mockClear();
+ vi.mocked(GlobalEmitter.off).mockClear();
+ });
+
+ it("registers prompt listener on mount", () => {
+ mountDialog();
+ expect(GlobalEmitter.on).toHaveBeenCalledWith("prompt", expect.any(Function));
+ });
+
+ it("shows dialog with default value when show is called", async () => {
+ const wrapper = mountDialog();
+ const showFn = GlobalEmitter.on.mock.calls.find((c) => c[0] === "prompt")?.[1];
+ expect(showFn).toBeDefined();
+ showFn({ message: "Version name?", defaultValue: "upload-1", resolve: vi.fn() });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.pendingPrompt).toEqual({ message: "Version name?" });
+ expect(wrapper.vm.inputValue).toBe("upload-1");
+ expect(wrapper.text()).toContain("Version name?");
+ });
+
+ it("calls resolve with input value when OK clicked", async () => {
+ const resolve = vi.fn();
+ const wrapper = mountDialog();
+ const showFn = GlobalEmitter.on.mock.calls.find((c) => c[0] === "prompt")?.[1];
+ showFn({ message: "Name?", defaultValue: "a", resolve });
+ await wrapper.vm.$nextTick();
+ wrapper.vm.inputValue = "reticulum-manual";
+ await wrapper.find("button.bg-blue-600").trigger("click");
+ expect(resolve).toHaveBeenCalledWith("reticulum-manual");
+ expect(wrapper.vm.pendingPrompt).toBeNull();
+ });
+
+ it("calls resolve(null) when Cancel clicked", async () => {
+ const resolve = vi.fn();
+ const wrapper = mountDialog();
+ const showFn = GlobalEmitter.on.mock.calls.find((c) => c[0] === "prompt")?.[1];
+ showFn({ message: "Name?", defaultValue: "a", resolve });
+ await wrapper.vm.$nextTick();
+ const cancelBtn = wrapper.findAll("button").find((b) => b.text() === "common.cancel");
+ await cancelBtn.trigger("click");
+ expect(resolve).toHaveBeenCalledWith(null);
+ expect(wrapper.vm.pendingPrompt).toBeNull();
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────